iT邦幫忙

2023 iThome 鐵人賽

DAY 1
0
Modern Web

一些讓你看來很強的全端- trcp 伴讀系列 第 10

Day-010. 一些讓你看來很強的全端 TRPC 伴讀 - TodoList (上)

  • 分享至 

  • xImage
  •  

今天要來簡單 demo crud 內容摟~ 所有 demo 都會在底下的 repo ,所以各位小夥伴可以自行取用~

🚩 code

先起一個 nextjs 專案

> npx create-next-app@latest todolist  

安裝套件

這是本次會用到的套件,比較特別的是本次會使用 react-hook-form 搭配 zod 提交表單,以及搭配 react-query-devtools 去觀察 api 的結果。

// package.json

"dependencies": {
    "@hookform/resolvers": "^3.3.1",
    "@prisma/client": "^5.2.0",
    "@tailwindcss/forms": "^0.5.6",
    "@tanstack/react-query": "^4.33.0",
    "@tanstack/react-query-devtools": "^4.33.0",
    "@trpc/client": "^10.38.0",
    "@trpc/next": "^10.37.1",
    "@trpc/react-query": "^10.38.0",
    "@trpc/server": "^10.38.0",
    "@types/node": "20.5.3",
    "@types/react": "18.2.21",
    "@types/react-dom": "18.2.7",
    "autoprefixer": "10.4.15",
    "clsx": "^2.0.0",
    "eslint": "8.47.0",
    "eslint-config-next": "13.4.19",
    "next": "13.4.19",
    "postcss": "8.4.28",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "react-hook-form": "^7.45.4",
    "react-icons": "^4.10.1",
    "tailwindcss": "3.3.3",
    "typescript": "5.1.6",
    "zod": "^3.22.2"
  },
  "devDependencies": {
    "prisma": "^5.2.0"
  }

開始之前先在專案添加 schema

src
└── validate
    └── api
        └── post.ts

這邊的先定義 postcrudschemazod 除了可以幫你 validate input 外,還可以透過 z.infer 幫你做型別推斷。

// ~src/validate/api/post.ts
import { z } from "zod";

export const getPostSchema = z.object({
  post_id: z.string()
})
export type GetPostSchema = z.infer<typeof getPostSchema>

export const createPostSchema = z.object({
  title: z.string().min(1, { message: 'title required' }),
  content: z.string().optional(),
  published: z.boolean().default(false)
})
export type CreatePostSchema = z.infer<typeof createPostSchema>


export const togglePostPuPublishedSchema = z.object({
  id: z.number(),
  published: z.boolean()
})
export type TogglePostPuPublishedSchema = z.infer<typeof togglePostPuPublishedSchema>


export const deletePostSchema = z.object({
  id: z.number()
})
export type DeletePostSchema = z.infer<typeof deletePostSchema>

切版

首先我們需要先處理 form component,首先在 src 資料夾中先新增 components 資料夾

src
└── components
    ├── Button.tsx
    ├── Input.tsx
    └── PostForm.tsx

Button

這邊值得說明的是我是透過 clsx 這個套件幫我整合 className ,主要目的是將有條事件構造 string 字段轉換成有序列的 string ,例如:

import { clsx } from 'clsx';

// Strings (variadic)
clsx('foo', true && 'bar', 'baz');
//=> 'foo bar baz'

這樣的寫法更加直觀與方便管理 className 內容。

import React, { PropsWithChildren } from 'react'
import clsx from "clsx"
interface ButtonProps extends PropsWithChildren {
  onClick?: () => void,
  type?: "button" | "submit" | "reset" | undefined;
  disabled?: boolean
  fullWidth?: boolean
  secondary?: boolean
  danger?: boolean
  className?: string
}
export const Button = ({
  onClick,
  type,
  disabled,
  fullWidth,
  secondary,
  danger,
  className,
  children,
}: ButtonProps) => {
  return (
    <button
      onClick={onClick}
      type={type}
      disabled={disabled}
      className={clsx(`
        flex 
        justify-center 
        rounded-md 
        px-3 
        py-2 
        text-sm 
        font-semibold 
        focus-visible:outline 
        focus-visible:outline-2 
        focus-visible:outline-offset-2 
        `,
        disabled && 'opacity-50 cursor-default',
        fullWidth && 'w-full',
        secondary ? 'text-gray-900' : 'text-white',
        danger && 'bg-rose-500 hover:bg-rose-600 focus-visible:outline-rose-600',
        !secondary && !danger && 'bg-sky-500 hover:bg-sky-600 focus-visible:outline-sky-600',
        className
      )}
    >
      {children}
    </button>
  )
}

Input

這邊是會在 reactHook form 中使用的 input,比較特別的是,registererror 這是 react hook form 中 useForm 傳下來的 props 。

import React, { ComponentProps, HTMLInputTypeAttribute, useState } from 'react'
import { FieldError, FieldErrors, FieldValues, Path, UseFormRegister } from 'react-hook-form'
import { AiFillEye, AiFillEyeInvisible } from 'react-icons/ai'
import clsx from "clsx";
interface InputProps<TForm extends FieldValues> extends ComponentProps<'input'> {
  label: string,
  id: Path<TForm>,
  register: UseFormRegister<TForm>,
  error: FieldError | undefined,
  type?: HTMLInputTypeAttribute
  require?: boolean
  disable?: boolean
}
export const Input = <TForm extends FieldValues>({
  register,
  id,
  label,
  error,
  disable,
  require,
  type = 'text',
  ...rest }: InputProps<TForm>) => {
  return (
    <div>
      <label
        htmlFor={id}
        className="
          text-sm 
          font-medium 
          leading-6 
          text-gray-900
          flex
          items-center        
        "
      >
        {label}
        {require && <span className='text-red-500'>*</span>}
      </label>
      <div className="mt-2 relative">
        <input
          type={type}
          className={clsx(
            `
            form-input
            block 
            w-full 
            rounded-md 
            border-0 
            py-1.5 
            text-gray-900 
            shadow-sm 
            ring-1 
            ring-inset 
            ring-gray-300 
            placeholder:text-gray-400 
            focus:ring-2 
            focus:ring-inset 
            sm:text-sm 
            sm:leading-6`,
            disable && 'opacity-50 cursor-default',
            error?.message ? ' focus:ring-rose-500' : 'focus:ring-sky-500',
          )}
          {...register(id)}
          {...rest}
        />
        {error && <p className='text-red-500'>{error.message}</p>}
      </div>
    </div>
  )
}

form

首先先加 zod schemaform submit 用,之後把 registererrors 分別給 Input 中使用。

import { RouterInputs, api } from '@/utils/api'
import { createPostSchema ,type CreatePostSchema} from '@/validate/api/post'
import { zodResolver } from '@hookform/resolvers/zod'
import React from 'react'
import { FieldError, FieldErrors, SubmitHandler, useForm } from 'react-hook-form'
import { Input } from './Input'
import { Button } from './Button'
import { queryClient } from './provider'

export const PostForm = () => {
  const { register, formState: { errors }, handleSubmit } = useForm<RouterInputs['posts']['addPost']>({
    resolver: zodResolver(createPostSchema),
    mode: 'onChange',
    defaultValues: {
      published: false
    }
  })
  const onSubmit: SubmitHandler<CreatePostSchema> = async (data) => {
console.log(data)
  }

  return (

    <div
      className="
        bg-white
          px-4
          py-8
          shadow
          sm:rounded-lg
          sm:px-10
        "
    >
      <form
        className="space-y-6"
        onSubmit={handleSubmit(onSubmit)}
      >
        <Input
          label='Title'
          register={register}
          id='title'
          disable={false}
          required
          error={errors.title}
        />
        <Input
          label='content'
          register={register}
          id='content'
          disable={false}
          error={errors.content}
        />
        <Button type='submit' disabled={false} fullWidth>submit</Button>
      </form>
    </div>
  );
}

之後把 PostForm 放到 src/page/index.tsx

// src/page/index.tsx 
import { PostForm } from "@/components/PostForm";

export default function Home() {
  return (
    <div className="bg-gray-100 min-h-screen overflow-y-auto p-4">
      <h2 className="text-center text-3xl">Create posts</h2>
      <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
        <PostForm />
      </div>

    </div>
  );
}

整個畫面應該長現在樣子

如果 Title 沒有加的話會有 error,這樣你就成功完成 react-hook-form 搭配 zod

Provider

這邊會定義 reqct query Provider 提供給 trpc 使用,使先新增 Provider.tsx

src
└── components
    ├── Button.tsx
    ├── Input.tsx
    ├── PostForm.tsx
    └── Provider.tsx

生成一個 queryClient 實例,同時添加 ReactQueryDevtools

// ~src/components/Provider.tsx
import React, { PropsWithChildren } from 'react'

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
export const queryClient = new QueryClient()
export const Provider = ({ children }: PropsWithChildren) => {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )
}

之後把 Provider 包到 _app.tsx

// !src/_app.tsx
import { api } from '@/utils/api'
import type { AppProps } from 'next/app'
import "@/styles/globals.css";
import { Provider } from '@/components/Provider';

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <Provider>
      <Component {...pageProps} />
    </Provider>)

}

export default api.withTRPC(MyApp);

如果畫面左下角有代表你成功添加 devtool 了。

這邊還差一個設定就是跟 trpc 共享同一個 queryClient,日後需要做 validate query cache 會用到。

// ~src/utils/api.ts
export const api = createTRPCNext<AppRouter>({
  config(opts) {
    return {
      links: [
        httpBatchLink({
          /**
           * If you want to use SSR, you need to use the server's full URL
           * @link https://trpc.io/docs/ssr
           **/
          url: `${getBaseUrl()}/api/trpc`,
          // maxURLLength: 2083, // 限制 413 Payload Too Large、414 URI Too Long和404 Not Found
          // You can pass any HTTP headers you wish here
          async headers() {
            return {
              // authorization: getAuthCookie(),
            };
          },
        }),
      ],
      queryClient
    };
  },
  /**
   * @link https://trpc.io/docs/ssr
   **/
  ssr: false,
});

get posts api

這邊會繼續沿用 day 7的 router,首先我們先把 router 歸類,post 就是我們這次的 todo list 會用到的 routeroot 則是所有 routerroot

src
├── server
    ├── api
    │   ├── post.ts
    │   ├── root.ts
    │   └── trpc.ts
    └── db.ts

讀筆可以仔細看看會發現 trpcrouter 是可以 nest 使用寫法跟 express 定義 router 非常像。

// ~/src/server/api/root
import * as trpc from '@trpc/server';
import { publicProcedure, router } from './trpc';
import { z } from 'zod';
import { TRPCError, initTRPC } from '@trpc/server';
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { postsRouter } from './post';

export const appRouter = router({
  greeting: publicProcedure
    .input(z.object({
      name: z.string()
    }))
    .query(({ input, ctx }) => `hello ${input.name} `),
  posts: postsRouter
});

// Export only the type of a router!
// This prevents us from importing server code on the client.
export type AppRouter = typeof appRouter;

首先先寫出 GET/ postGET/ postsGET/ posts 比較簡單就是直接 findManyGET/ post 則是會去檢查是否有 post ,沒有就 throw error,這邊的 TRPCErrortrpc 自己封裝的 error response ,可以透過 codemessage 定義你的內容。

// ~/src/server/api/post
import { z } from "zod";
import { publicProcedure, router } from "./trpc";
import { TRPCError } from "@trpc/server";
import {
  getPostSchema,
  createPostSchema,
} from "@/validate/api/post";

export const postsRouter = router({
  getPosts: publicProcedure
    .query(async ({ ctx }) => {
      const { prisma } = ctx
      const posts = await prisma.post.findMany({})
      return posts
    }),
  getPost: publicProcedure.input(getPostSchema)
    .query(async ({ input, ctx }) => {
      const { post_id } = input
      const { prisma } = ctx
      const post = await prisma.post.findFirst({
        where: {
          id: Number(post_id)
        }
      })
      if (!post) {
        throw new TRPCError({ code: 'NOT_FOUND', message: 'post not found' })
      }
      return post
    })
})

之後我們到 index.page 這邊因為你上面有定義 getPosts 這個 route,所以 api這個 trpcinstance,會自動有型別提示有getPosts 內容,最後根據你的 routequery 還是 mutate 決定呼叫什麼,這邊的 getPostsquery ,所以是 useQuery

useQuery 這邊有幾個主要的 return value 這邊簡單跟讀者介紹:

  1. data : 在 getPosts 中 return 的 value。
  2. isLoading : 檢查 api loading 狀態。

有了 react query 的搭配整 loading state 寫法更簡潔了~

import { PostForm } from "@/components/PostForm";
import { api } from "@/utils/api";
import { AiFillDelete } from "react-icons/ai";

export default function Home() {
  const { data: posts, isLoading } = api.posts.getPosts.useQuery()
 
  if (isLoading) return 'isLoading'
  return (
    <div className="bg-gray-100 min-h-screen overflow-y-auto p-4">
      <h2 className="text-center text-3xl">Create posts</h2>
      <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
        <PostForm />
        <ul className="flex flex-col gap-[1rem] justify-center mt-5">
          {posts.map((post, index) => (
            <li key={post.id} className="flex items-center justify-between">
              <label
                htmlFor=""
                className={`
                  text-2xl 
                  ${!!post.published && "line-through"}
                `}
              >{post.title}</label>
              <AiFillDelete
                color="red"
                className="cursor-pointer"
                size={20}
              />
            </li>
          ))}
        </ul>
      </div>

    </div>
  );
}

但因為現在沒有資料所以底下是空的,所以我們需要先添加一些 post data~

讀者還記得昨天介紹的 prisma studio 嗎~ prisma studio 除了可以查看資料結果外,你還可以手動添加 record data 或是 delete data ,甚至是 filter output 都很方便~

這邊補一個 post schema 給讀者對照

model Post {
  id        Int     @id @default(autoincrement())
  title     String
  content   String?
  published Boolean @default(false)
}

首先先在終端機打一下 prisma studio cli

> npx prisma studio 

打開 studio -> 點選 post -> add record

因為 title 是必填的所以只需要填寫 title 欄位就好,其餘的欄位可以隨讀者心情看要不要改。


筆者先添加兩筆資料就好

最後看畫面恭喜你成功抓到資料了~

同時查看 react query devtoolquery cache 也有東西了。

今天內容先到這邊,明天我們繼續完成 CRUD~

✅ 前端社群 :
https://lihi3.cc/kBe0Y


上一篇
Day-09. 一些讓你看來很強的全端 TRPC 伴讀 - prisma
下一篇
Day-011. 一些讓你看來很強的全端 TRPC 伴讀 - TodoList (下)
系列文
一些讓你看來很強的全端- trcp 伴讀30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言